diff options
Diffstat (limited to 'app/[lng]/test/table-v2/page.tsx')
| -rw-r--r-- | app/[lng]/test/table-v2/page.tsx | 630 |
1 files changed, 630 insertions, 0 deletions
diff --git a/app/[lng]/test/table-v2/page.tsx b/app/[lng]/test/table-v2/page.tsx new file mode 100644 index 00000000..e7fb5bdd --- /dev/null +++ b/app/[lng]/test/table-v2/page.tsx @@ -0,0 +1,630 @@ +"use client"; + +import * as React from "react"; +import { PaginationState, SortingState, ColumnFiltersState, GroupingState } from "@tanstack/react-table"; +import { ClientVirtualTable } from "@/components/client-table-v2/client-virtual-table"; +import { TestProduct } from "@/db/schema/test-table-v2"; +import { productColumns, orderColumns } from "./columns"; +import { OrderWithDetails } from "./column-defs"; +import { + getAllProducts, + getProductTableData, + getOrderTableData, + getProductTableDataWithGrouping, + GroupInfo, +} from "./actions"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { ChevronDown, ChevronRight, Loader2 } from "lucide-react"; +import { cn } from "@/lib/utils"; + +// ============================================================ +// Reusable Loading Overlay Component +// ============================================================ + +function LoadingOverlay({ + isLoading, + children +}: { + isLoading: boolean; + children: React.ReactNode +}) { + return ( + <div className="relative"> + {children} + {isLoading && ( + <div className="absolute inset-0 z-50 flex items-center justify-center bg-background/60 backdrop-blur-[2px] transition-all duration-200"> + <div className="flex items-center gap-2 px-4 py-2 bg-background rounded-lg shadow-lg border"> + <Loader2 className="h-5 w-5 animate-spin text-primary" /> + <span className="text-sm text-muted-foreground">Loading...</span> + </div> + </div> + )} + </div> + ); +} + +// ============================================================ +// Pattern 1: Client-Side Table +// ============================================================ + +function ClientSideTable() { + const [data, setData] = React.useState<TestProduct[]>([]); + const [isLoading, setIsLoading] = React.useState(true); + + React.useEffect(() => { + const fetchData = async () => { + setIsLoading(true); + try { + const products = await getAllProducts(); + setData(products); + } catch (error) { + console.error("Failed to fetch products:", error); + } finally { + setIsLoading(false); + } + }; + + fetchData(); + }, []); + + return ( + <Card> + <CardHeader> + <div className="flex items-center gap-2"> + <CardTitle>Pattern 1: Client-Side</CardTitle> + <Badge variant="outline">fetchMode="client"</Badge> + </div> + <CardDescription> + 모든 데이터를 한 번에 받아와 클라이언트에서 필터링/정렬/페이지네이션/그룹핑 처리합니다. + <br /> + <span className="text-muted-foreground"> + 적합: 데이터 1000건 이하, 빠른 인터랙션 필요 시 + </span> + <br /> + <span className="text-emerald-600 text-sm"> + ✅ 그룹핑: 헤더 우클릭 → Group by [Column] + </span> + </CardDescription> + </CardHeader> + <CardContent> + <LoadingOverlay isLoading={isLoading}> + <div className="h-[500px]"> + <ClientVirtualTable + fetchMode="client" + data={data} + columns={productColumns} + isLoading={false} // LoadingOverlay로 처리 + enablePagination + enableGrouping + height="100%" + /> + </div> + </LoadingOverlay> + </CardContent> + </Card> + ); +} + +// ============================================================ +// Pattern 2: Factory Service (Server-Side) +// ============================================================ + +function FactoryServiceTable() { + const [data, setData] = React.useState<TestProduct[]>([]); + const [totalRows, setTotalRows] = React.useState(0); + const [isLoading, setIsLoading] = React.useState(true); + + // Table state + const [pagination, setPagination] = React.useState<PaginationState>({ + pageIndex: 0, + pageSize: 10, + }); + const [sorting, setSorting] = React.useState<SortingState>([]); + const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]); + const [globalFilter, setGlobalFilter] = React.useState(""); + + // Fetch data on state change + React.useEffect(() => { + const fetchData = async () => { + setIsLoading(true); + try { + const result = await getProductTableData({ + pagination, + sorting, + columnFilters, + globalFilter, + }); + setData(result.data); + setTotalRows(result.totalRows); + } catch (error) { + console.error("Failed to fetch products:", error); + } finally { + setIsLoading(false); + } + }; + + fetchData(); + }, [pagination, sorting, columnFilters, globalFilter]); + + return ( + <Card> + <CardHeader> + <div className="flex items-center gap-2"> + <CardTitle>Pattern 2: Factory Service</CardTitle> + <Badge variant="outline">fetchMode="server"</Badge> + <Badge variant="secondary">createTableService</Badge> + </div> + <CardDescription> + <code>createTableService</code>로 서버 액션을 자동 생성합니다. + <br /> + <span className="text-muted-foreground"> + 적합: 단순 CRUD, 마스터 테이블 조회 + </span> + <br /> + <span className="text-amber-600 text-sm"> + ⚠️ 그룹핑: 서버 모드에서는 별도 구현 필요 (Pattern 2-B 참고) + </span> + </CardDescription> + </CardHeader> + <CardContent> + <LoadingOverlay isLoading={isLoading}> + <div className="h-[500px]"> + <ClientVirtualTable + fetchMode="server" + data={data} + rowCount={totalRows} + columns={productColumns} + isLoading={false} + enablePagination + enableGrouping={false} + height="100%" + pagination={pagination} + onPaginationChange={setPagination} + sorting={sorting} + onSortingChange={setSorting} + columnFilters={columnFilters} + onColumnFiltersChange={setColumnFilters} + globalFilter={globalFilter} + onGlobalFilterChange={setGlobalFilter} + /> + </div> + </LoadingOverlay> + </CardContent> + </Card> + ); +} + +// ============================================================ +// Pattern 2-B: Server-Side Grouping (Context Menu 방식) +// ============================================================ + +function ServerGroupingTable() { + const [grouping, setGrouping] = React.useState<GroupingState>([]); + const [expandedGroups, setExpandedGroups] = React.useState<string[]>([]); + const [groups, setGroups] = React.useState<GroupInfo[]>([]); + const [flatData, setFlatData] = React.useState<TestProduct[]>([]); + const [isGrouped, setIsGrouped] = React.useState(false); + const [isLoading, setIsLoading] = React.useState(true); + const [totalRows, setTotalRows] = React.useState(0); + + const [pagination, setPagination] = React.useState<PaginationState>({ + pageIndex: 0, + pageSize: 10, + }); + + // 데이터 페칭 + React.useEffect(() => { + const fetchData = async () => { + setIsLoading(true); + try { + const result = await getProductTableDataWithGrouping( + { pagination, grouping }, + expandedGroups + ); + + if ('groups' in result) { + setGroups(result.groups); + setIsGrouped(true); + setFlatData([]); + } else { + setFlatData(result.data); + setTotalRows(result.totalRows); + setIsGrouped(false); + setGroups([]); + } + } catch (error) { + console.error("Failed to fetch:", error); + } finally { + setIsLoading(false); + } + }; + + fetchData(); + }, [pagination, grouping, expandedGroups]); + + // 그룹 토글 + const toggleGroup = (groupKey: string) => { + setExpandedGroups(prev => + prev.includes(groupKey) + ? prev.filter(k => k !== groupKey) + : [...prev, groupKey] + ); + }; + + // 그룹핑 상태 변경 핸들러 (Context Menu에서 호출됨) + const handleGroupingChange = React.useCallback((updater: GroupingState | ((old: GroupingState) => GroupingState)) => { + const newGrouping = typeof updater === 'function' ? updater(grouping) : updater; + setGrouping(newGrouping); + setExpandedGroups([]); // 그룹핑 변경 시 확장 상태 초기화 + }, [grouping]); + + return ( + <Card> + <CardHeader> + <div className="flex items-center gap-2"> + <CardTitle>Pattern 2-B: Server-Side Grouping</CardTitle> + <Badge variant="outline">fetchMode="server"</Badge> + <Badge className="bg-emerald-500">GROUP BY</Badge> + </div> + <CardDescription> + 서버에서 GROUP BY + 집계 쿼리로 그룹 정보를 조회합니다. + <br /> + <span className="text-emerald-600 text-sm"> + ✅ 그룹핑: 헤더 우클릭 → Group by [Column] (category, status, isNew만 지원) + </span> + </CardDescription> + </CardHeader> + <CardContent className="space-y-4"> + {/* 현재 그룹핑 상태 표시 */} + {grouping.length > 0 && ( + <div className="flex items-center gap-2 text-sm"> + <span className="text-muted-foreground">Grouped by:</span> + {grouping.map((col) => ( + <Badge key={col} variant="secondary"> + {col} + <button + className="ml-1 hover:text-destructive" + onClick={() => setGrouping([])} + > + × + </button> + </Badge> + ))} + </div> + )} + + {/* Content with Loading Overlay */} + <LoadingOverlay isLoading={isLoading}> + <div className="border rounded-md min-h-[400px] max-h-[500px] overflow-auto"> + {isGrouped ? ( + // Grouped View - Custom Rendering + <div className="divide-y"> + {groups.length === 0 ? ( + <div className="flex items-center justify-center h-[400px] text-muted-foreground"> + No data + </div> + ) : ( + groups.map((group) => ( + <div key={group.groupKey}> + {/* Group Header */} + <button + className="w-full px-4 py-3 flex items-center gap-3 hover:bg-muted/50 transition-colors text-left" + onClick={() => toggleGroup(group.groupKey)} + > + {expandedGroups.includes(group.groupKey) ? ( + <ChevronDown className="w-4 h-4" /> + ) : ( + <ChevronRight className="w-4 h-4" /> + )} + <span className="font-medium"> + {grouping[0]}: <Badge variant="outline">{String(group.groupValue)}</Badge> + </span> + <span className="text-muted-foreground text-sm"> + ({group.count} items) + </span> + </button> + + {/* Expanded Rows */} + {expandedGroups.includes(group.groupKey) && group.rows && ( + <div className="bg-muted/20 border-t"> + <table className="w-full text-sm"> + <thead> + <tr className="border-b bg-muted/30"> + <th className="px-4 py-2 text-left">ID</th> + <th className="px-4 py-2 text-left">SKU</th> + <th className="px-4 py-2 text-left">Name</th> + <th className="px-4 py-2 text-left">Price</th> + <th className="px-4 py-2 text-left">Stock</th> + </tr> + </thead> + <tbody> + {group.rows.map((row) => ( + <tr key={row.id} className="border-b hover:bg-muted/30"> + <td className="px-4 py-2">{row.id}</td> + <td className="px-4 py-2 font-mono text-xs">{row.sku}</td> + <td className="px-4 py-2">{row.name}</td> + <td className="px-4 py-2"> + {new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(parseFloat(row.price))} + </td> + <td className="px-4 py-2">{row.stock}</td> + </tr> + ))} + </tbody> + </table> + </div> + )} + </div> + )) + )} + </div> + ) : ( + // Normal Table View with Context Menu Grouping + <ClientVirtualTable + fetchMode="server" + data={flatData} + rowCount={totalRows} + columns={productColumns} + enablePagination + enableGrouping // Context Menu에서 Group By 옵션 활성화 + height="400px" + pagination={pagination} + onPaginationChange={setPagination} + // 그룹핑 상태 연결 + grouping={grouping} + onGroupingChange={handleGroupingChange} + /> + )} + </div> + </LoadingOverlay> + </CardContent> + </Card> + ); +} + +// ============================================================ +// Pattern 3: Custom Service (Server-Side with Joins) +// ============================================================ + +function CustomServiceTable() { + const [data, setData] = React.useState<OrderWithDetails[]>([]); + const [totalRows, setTotalRows] = React.useState(0); + const [isLoading, setIsLoading] = React.useState(true); + + // Table state + const [pagination, setPagination] = React.useState<PaginationState>({ + pageIndex: 0, + pageSize: 10, + }); + const [sorting, setSorting] = React.useState<SortingState>([]); + const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]); + const [globalFilter, setGlobalFilter] = React.useState(""); + + // Fetch data on state change + React.useEffect(() => { + const fetchData = async () => { + setIsLoading(true); + try { + const result = await getOrderTableData({ + pagination, + sorting, + columnFilters, + globalFilter, + }); + setData(result.data); + setTotalRows(result.totalRows); + } catch (error) { + console.error("Failed to fetch orders:", error); + } finally { + setIsLoading(false); + } + }; + + fetchData(); + }, [pagination, sorting, columnFilters, globalFilter]); + + return ( + <Card> + <CardHeader> + <div className="flex items-center gap-2"> + <CardTitle>Pattern 3: Custom Service</CardTitle> + <Badge variant="outline">fetchMode="server"</Badge> + <Badge variant="secondary">DrizzleTableAdapter</Badge> + </div> + <CardDescription> + <code>DrizzleTableAdapter</code>를 도구로 사용하여 복잡한 조인 쿼리를 직접 작성합니다. + <br /> + <span className="text-muted-foreground"> + 적합: 여러 테이블 조인, 복잡한 비즈니스 로직 + </span> + <br /> + <span className="text-amber-600 text-sm"> + ⚠️ 그룹핑: 가상 컬럼(조인 결과)은 서버 GROUP BY 불가 + </span> + </CardDescription> + </CardHeader> + <CardContent> + <LoadingOverlay isLoading={isLoading}> + <div className="h-[500px]"> + <ClientVirtualTable + fetchMode="server" + data={data} + rowCount={totalRows} + columns={orderColumns} + isLoading={false} + enablePagination + enableGrouping={false} + height="100%" + pagination={pagination} + onPaginationChange={setPagination} + sorting={sorting} + onSortingChange={setSorting} + columnFilters={columnFilters} + onColumnFiltersChange={setColumnFilters} + globalFilter={globalFilter} + onGlobalFilterChange={setGlobalFilter} + /> + </div> + </LoadingOverlay> + </CardContent> + </Card> + ); +} + +// ============================================================ +// Main Page +// ============================================================ + +export default function TableV2TestPage() { + return ( + <div className="container py-6 space-y-6"> + <div> + <h1 className="text-3xl font-bold tracking-tight"> + ClientVirtualTable V2 - 데이터 페칭 패턴 테스트 + </h1> + <p className="text-muted-foreground mt-2"> + GUIDE.md에 정의된 데이터 페칭 패턴과 그룹핑 처리 방법을 테스트합니다. + <br /> + 테스트 전 시딩이 필요합니다: <code className="bg-muted px-1 rounded">npx tsx db/seeds/test-table-v2.ts</code> + </p> + </div> + + <Tabs defaultValue="pattern1" className="space-y-4"> + <TabsList className="grid w-full grid-cols-4"> + <TabsTrigger value="pattern1"> + 1. Client-Side + </TabsTrigger> + <TabsTrigger value="pattern2"> + 2. Factory Service + </TabsTrigger> + <TabsTrigger value="pattern2b"> + 2-B. Server Grouping + </TabsTrigger> + <TabsTrigger value="pattern3"> + 3. Custom Service + </TabsTrigger> + </TabsList> + + <TabsContent value="pattern1"> + <ClientSideTable /> + </TabsContent> + + <TabsContent value="pattern2"> + <FactoryServiceTable /> + </TabsContent> + + <TabsContent value="pattern2b"> + <ServerGroupingTable /> + </TabsContent> + + <TabsContent value="pattern3"> + <CustomServiceTable /> + </TabsContent> + </Tabs> + + {/* Summary Table */} + <Card> + <CardHeader> + <CardTitle>패턴별 그룹핑 지원 현황</CardTitle> + </CardHeader> + <CardContent> + <div className="overflow-x-auto"> + <table className="w-full text-sm"> + <thead> + <tr className="border-b"> + <th className="text-left py-2 px-4">패턴</th> + <th className="text-left py-2 px-4">그룹핑 방식</th> + <th className="text-left py-2 px-4">가상 컬럼 지원</th> + <th className="text-left py-2 px-4">비고</th> + </tr> + </thead> + <tbody> + <tr className="border-b"> + <td className="py-2 px-4 font-medium">1. Client-Side</td> + <td className="py-2 px-4"> + <Badge className="bg-emerald-500">TanStack Grouping</Badge> + </td> + <td className="py-2 px-4"> + <Badge className="bg-emerald-500">✓ 지원</Badge> + </td> + <td className="py-2 px-4 text-muted-foreground"> + 메모리에서 처리, 전체 데이터 필요 + </td> + </tr> + <tr className="border-b"> + <td className="py-2 px-4 font-medium">2. Factory Service</td> + <td className="py-2 px-4"> + <Badge variant="outline">미지원</Badge> + </td> + <td className="py-2 px-4">-</td> + <td className="py-2 px-4 text-muted-foreground"> + 별도 구현 필요 (2-B 참고) + </td> + </tr> + <tr className="border-b"> + <td className="py-2 px-4 font-medium">2-B. Server Grouping</td> + <td className="py-2 px-4"> + <Badge className="bg-blue-500">DB GROUP BY</Badge> + </td> + <td className="py-2 px-4"> + <Badge variant="destructive">✗ 불가</Badge> + </td> + <td className="py-2 px-4 text-muted-foreground"> + serverGroupable 컬럼만 가능 + </td> + </tr> + <tr> + <td className="py-2 px-4 font-medium">3. Custom Service</td> + <td className="py-2 px-4"> + <Badge variant="secondary">커스텀 구현</Badge> + </td> + <td className="py-2 px-4"> + <Badge variant="secondary">선택적</Badge> + </td> + <td className="py-2 px-4 text-muted-foreground"> + 쿼리 설계에 따라 다름 + </td> + </tr> + </tbody> + </table> + </div> + </CardContent> + </Card> + + {/* Column Groupability Info */} + <Card> + <CardHeader> + <CardTitle>컬럼별 서버 그룹핑 지원 여부</CardTitle> + <CardDescription> + <code>meta.serverGroupable</code> 플래그로 DB GROUP BY 가능 여부를 표시합니다. + <br /> + 헤더 우클릭 시 "Group by [Column]" 메뉴가 표시됩니다. + </CardDescription> + </CardHeader> + <CardContent> + <div className="flex flex-wrap gap-2"> + {productColumns.map((col) => { + if (!('accessorKey' in col)) return null; + const meta = col.meta as { serverGroupable?: boolean } | undefined; + const isGroupable = meta?.serverGroupable; + return ( + <Badge + key={col.accessorKey as string} + variant={isGroupable ? "default" : "outline"} + className={isGroupable ? "bg-emerald-500" : ""} + > + {col.accessorKey as string} + {isGroupable && " ✓"} + </Badge> + ); + })} + </div> + </CardContent> + </Card> + </div> + ); +} |
